
系列:30 天打造企業級 PLM|面向:全端
「找出上季發行、供應商是 X、狀態不是停產的所有料號。」使用者要的是自己組條件,不想每次都跑來拜託工程師寫 SQL。難點在 Day 4 就埋下了:欄位是 metadata 定義的,有幾百個而且一直在改,搜尋引擎不能認得任何一個具體欄位。今天就透過前端條件建構器與後端動態 Predicate 怎麼合作解掉這題。
進階搜尋實機畫面:

在以前 Oracle Agile PLM 中,進階搜尋(Advanced Search / Parametric Search)依賴的是一套內建於 EJB 核心的私有 Criteria 查詢引擎。
在舊架構下,搜尋條件是透過內部 Attribute ID 拼裝成龐大的專屬 SQL。當查詢條件跨越多個 Page Two / Page Three 動態欄位時,底層 SQL 會膨脹成多層子查詢與外部連接,極易引發 Oracle 資料庫的全表掃描(Full Table Scan);更棘手的是,Agile 的 Saved Search 格式完全封閉在系統內部,外部 REST 用戶端根本無法直接重放或自訂條件樹。
Mini-PLM 的解法是將查詢條件抽象為標準的 JSON 條件樹,後端透過 JPA Criteria API 進行遞迴 Predicate 建構,搜尋引擎對欄位的全部認知只有一筆傳輸格式:

這張圖把資料流和設定重用放在同一張圖裡:上半部是一次搜尋如何從條件建構器走到結果,下半部則是同一個 ConfigCriteriaNode 如何再被權限、流程關卡與 SavedSearch 選單引用。這裡最重要的架構邊界有兩個:fieldSource 決定欄位解析要走實體欄位或動態值表;條件樹本身則不綁死求值方式,可以交給資料庫產生 JPA Predicate,也可以在記憶體端轉成 Boolean。如此一來,欄位增加時由 metadata 驅動,權限與流程增加時則重用同一套條件語意。
{ fieldKey, operator, compareValue, fieldSource }
關鍵是 fieldSource。欄位分兩種出身:實體欄位(表單編號、建立日期這種長在資料表欄位上的)與動態欄位(Day 4 存在 key-value 值表裡的)。同一套 Predicate 建構器依 fieldSource 走兩條路,實體欄位直接 root.get(fieldKey);動態欄位則 join 值表,以「key 等於 fieldKey 且 value 符合條件」的 subquery 表達。查詢引擎不認識任何具體欄位,只認識欄位的型態與出身。新欄位加進 metadata,搜尋自動支援,零程式改動。
Operator 集合由欄位型態決定(metadata 又一次當單一事實來源):date 型有 IsToday / EqualTo / Range,select/radio 型有 EQ / NotEq / In / IsNull。前端只渲染合法 operator,後端對每種型態做值轉換,date 字串轉區間、multilist 做 JSON 包含比對。
條件支援群組巢狀(AND/OR 括號),後端以遞迴走樹產生 JPA Predicate(CriteriaTreePredicateBuilder 實碼,全文 53 行):
/** 遞迴求單一節點的 Predicate;回傳 null 代表此節點無約束(應被父層略過)。 */
private static Predicate walk(CriteriaTreeNode node, LeafPredicateFn leafFn, CriteriaBuilder cb) {
if (type != CriteriaNodeTypeEnum.GROUP) {
return leafFn.toPredicate(node.getItem()); // 葉子:委派欄位級轉換
}
List<Predicate> childPreds = new ArrayList<>();
for (CriteriaTreeNode child : node.getChildren()) {
Predicate cp = walk(child, leafFn, cb);
if (cp != null) childPreds.add(cp); // null=無約束,略過
}
if (childPreds.isEmpty()) {
return cb.conjunction(); // 群組剪空 → 恆真,不是拋錯
}
return node.getItem().getGroupLogical() == LogicalEnum.OR
? cb.or(arr) : cb.and(arr);
}
兩個防禦性語意要留意。葉子回 null 表示「無約束」(例如 SavedSearch 的 prompt 欄位使用者沒填值),由父層略過;群組為空回 cb.conjunction()(恆真)而不拋例外——條件全被略過的搜尋應該退化成 match-all,這行為是修過 bug 之後才明確定義的(見下)。
架構上,Form / Item / User / UserGroup 四種搜尋對象共用同一顆樹建構器,各自提供 LeafPredicateFn(欄位怎麼轉 Predicate 因對象而異)。樹的邏輯只寫一次。
前端條件建構器(EditableProTable 行編輯)是全系列修最多輪的元件。四天四個 fix commit,實錄如下:
還有一個更根本的:EditableProTable 與 Form 值不同步。行編輯元件包在 Form.Item 裡時,讀與寫都不走 Form 的資料流。儲存前要 mergeLiveFormValues(把表格即時值合併回 Form),載入後要 rowsToFormValues(把資料還原成表格列),雙向橋接缺一不可。進階元件的內部狀態與表單狀態是兩個世界,橋沒搭好就是「畫面上有、存下來沒有」。
搜尋的終點不是「看到結果」,是拿結果去做事。最常見的一件事:把查到的料號夾帶進變更表單(Impacted Items)。Mini-PLM 給了三條動線,成本由低到高排。
拖曳。搜尋結果列、結果追蹤面板(useResultTrackStore,跨查詢收集品項的暫存籃)裡的品項都是拖曳來源,走 Day 14 的 dataTransfer 協定;面板多選之後拖出,payload 自動升級成 ITEM_BATCH,一次夾帶整批。表單端的 AffectedItems 接住 drop,逐筆掛入後彈同款 Drop Summary 總結成敗。
貼上。不是每個使用者都習慣拖曳,更多人手上是一張 Excel 料號清單。批次輸入框直接吃貼上內容,分隔符容忍 ;、換行、Tab、逗號(Excel 各種複製姿勢都涵蓋),一次解析、逐筆加入、總結回報。輸入框還做了一層貼心:正在輸入的最後一段料號達三碼就觸發 debounce 300ms 的自動搜尋補全,單筆輸入與批次貼上共用同一個入口,「輸入一筆即單個加入,輸入多筆即批次加入」。
點擊。傳統的逐筆搜尋加入仍在,當作保底。
三條動線背後是同一個設計判斷:批次動作的失敗要逐筆記帳。無論拖三十筆還是貼三十筆,部分成功是常態(重複、無權限、料號打錯),Summary 列出每一筆失敗與原因,使用者修完清單再貼一次,而不是對著「操作失敗」四個字猜。
順帶一提,搜尋結果欄位組合(ColumnProfile)的欄位順序調整用的是 @dnd-kit/sortable 的拖放排序(ColumnSettingsModal)——同一個產品裡,元件內排序用 dnd-kit、跨頁搬運物件用原生協定,Day 14 定下的分工在這裡再次成立。
前三節把條件樹當「搜尋語言」講,但它在 Mini-PLM 裡的身分其實是可重用的設定物件(ConfigCriteriaNode)。同一顆樹存進資料庫之後,被三個彼此無關的子系統引用,而且全部由管理者在畫面上設定,不需要改程式。
權限(Privilege)可以掛一顆條件樹。AuthorizationService.getMyModifyFields 走訪使用者的權限時,沒掛條件的直接生效;掛了條件的先呼叫 matchFormByCriteria(formId, criteriaNode),這張表單命中條件才把該權限的欄位集合併進來。於是「採購只能改『狀態=草稿』表單的價格欄位」這種需求,不是寫 if-else,而是:建一條「狀態=草稿」的搜尋條件,再把它綁到採購角色的 MODIFY 權限上。Item 權限走同一套(matchItemByCriteria)。
流程關卡(ConfigStepCriteria)也是同一個掛法。一個關卡可以掛多組「條件+簽核人/觀察者/通知人/必填欄位」,簽核人解析(StepCriteriaApproverResolver)與必填欄位檢查(ActionService.isCurrentUserMatchedStepCriteria)都先用 matchFormByCriteria 判定這張表單命中哪幾組,再套用該組的名單與必填清單。「金額超過一百萬要加副總簽核、且必須填寫預算科目」——建一條「金額 > 1000000」的條件,綁到關卡上,設定完成。
搜尋條件儲存後(SavedSearch),可以直接掛到選單。Menu 實體有一個 configCriteriaNode 關聯;管理者在 ConfigMenu 建選單項時選一條 SavedSearch,前端路由就變成 /saved-search/:criteriaNodeId/:label,SavedSearch.tsx 依 criteriaNodeId 撈回條件樹、依 sourceType(FORM / ITEM / USER / USERGROUP)切換對應的結果元件,然後自動執行。
「我的待簽核 ECO」「本週新開的 Part」「三個月沒登入的帳號」——這些每天要看的清單,過去是每個人自己記條件,現在是左側選單裡的一個項目。條件樹裡有留「prompt 欄位」(值空著、執行時彈窗要使用者填)的話,一條 SavedSearch 就能當參數化報表用:同一個「依供應商查料」選單,點下去先問供應商。Day 4 提到「葉子回 null 表示無約束」的防禦語意,就是替這個場景設計的——prompt 沒填,該條件略過而不是搜尋炸掉。
選單綁的是「條件 ID」而不是「條件內容的複本」。管理者修了 SavedSearch,掛它的每個選單同步生效;同一條件也能掛在不同角色的選單樹上。
搜尋結果預設欄位對誰都不對。採購要看單價與供應商,品保要看批號與檢驗狀態,同一批 ECO 兩邊要的欄位完全不重疊。ColumnProfile(欄位範本)解這題:使用者在結果表格的 Column Settings 裡任意勾選欄位、拖拉排序(@dnd-kit/sortable)、調寬度,存成一個具名範本。
範本有兩種 scope:
MANAGE_GLOBAL_COLUMN_PROFILE 權限範本依 sourceType 與 formType / itemType 隔離(ECO 的範本不會混進 Part 的清單),且與 SavedSearch 可以綁定:一條 SavedSearch 記住「用哪個欄位範本呈現」(ConfigCriteriaNode.columnProfileId),掛到選單後點開就是「對的條件+對的欄位」,不必每次重新勾。管理者把常用的組合做成 GLOBAL 範本,新進同仁第一天就能套用老手的視角,這是「隱性知識變資產」的另一半。
服務層的一條規則值得記:更新範本只能改名稱與欄位設定,不能改 scope、owner、type。要從個人升成全域,另存一份而不是原地改,否則已引用該範本的 SavedSearch 語意會在使用者不知情下被換掉。
動態查詢能成立,靠的是查詢引擎只認 metadata、不認具體欄位:fieldSource 分流、型態決定 operator、遞迴樹組 Predicate。另一半是實戰磨出來的防禦性語意(null 略過、為空恆真)跟前端狀態橋接。這部分書上不會教,都是修出來的。
再往上一層看,條件樹不只是搜尋語言:它是設定物件,同一顆樹綁到權限就是欄位授權條件、綁到關卡就是簽核路由條件、綁到選單就是一鍵重放的清單,再配上可分享的欄位範本決定「看什麼」。搜尋引擎寫一次,管理者拿它在畫面上組出權限與流程規則,不用再找工程師。
明日 Day 17:前端架構——九個 Zustand store 的切片策略與 routeMap 動態路由。